-
Notifications
You must be signed in to change notification settings - Fork 207
Implement play queue sync for Subsonic #1241
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Implement play queue sync for Subsonic #1241
Conversation
|
Thanks. Looks good on the first glance. I'll do some testing later once I have time. |
| if (isset($current)) | ||
| $playQueue['current'] = $current; | ||
| if (isset($position)) | ||
| $playQueue['position'] = $position; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's a good habit to always use curly braces on your if statements. For one famous example of what might happen if you don't, see goto fail.
| foreach ($playQueue['entry'] as &$entry) { | ||
| [$type, $id] = self::parseEntityId($entry); | ||
| if ($type === 'track') { | ||
| $entry = $this->trackToApi( | ||
| $this->trackBusinessLayer->find($id, $this->user()) | ||
| ); | ||
| } else if ($type === 'podcast_episode') { | ||
| $entry = $this->podcastEpisodeBusinessLayer->find($id, $this->user())->toSubsonicApi(); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wonder about this section. If an $entry somehow doesn't match either "track" or "podcast_episode", it'll be left in the response as an invalid element. Are there any other types of media that might end up saved in savePlayQueue? If this is a reasonable concern (and I dunno if it is), I can solve it by sanitizing the input on savePlayQueue or by generating a new array of just the valid items from $playQueue['entry'] in getPlayQueue
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's a good question. The third kind of media playable on the Subsonic clients is a radio station. I'm not certain if any client puts those to the play queue, though. The API would allow also video playback, but this is not supported in the Music app (at least for now or in the near future).
But yeah, it would probably be safer to construct the list so that only the supported types of entities would be retained.
|
Sorry for the delay, I have been busy with other things but now I managed to do some testing with this. Functionally, I think this works pretty much as it should. There's one minor deviation from the Subsonic XML schema https://www.subsonic.org/pages/inc/api/schema/subsonic-rest-api-1.16.1.xsd as there the attribute However, there's also one major issue now and that's the performance. The execution time of The good news is that this is a kind of problem which has been already solved elsewhere in the Subsonic API. There exists the function Caveat: I didn't try to run this, syntax errors are possible. While at it, one thing to consider is what will happen in case the queue contains an ID which doesn't match any item in the library. This could happen for example if the item has been deleted since the queue was saved. At the moment, the action Edit: I made another optimization on the example code above. Also the call to Edit2: Using |
|
Thanks for the review! I appreciate the testing/feedback and I'll work on those improvements. Unsure if I'll get more commits pushed until next weekend, though |
Funny! I was just looking at the suggestion and wondering if bulk track find functionality existed :) |
| $playQueue = json_decode($this->configManager->getUserValue($this->user(), $this->appName, 'play_queue', 'false'), true); | ||
|
|
||
| if (!$playQueue) { | ||
| return $this->subsonicResponse([]); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm returning an empty response rather than an empty playqueue when we haven't saved one. Seems better than generating metadata for a canned response, and it follows the API better: https://subsonic.org/pages/api.jsp#getPlayQueue
Returns a <subsonic-response> element with a nested <playQueue> element on success, or an empty <subsonic-response> if no play queue has been saved.
| $pqEntries[] = $apiTracks[$id]; | ||
| } else if ($type === 'podcast_episode') { | ||
| try { | ||
| $pqEntries[] = $this->podcastEpisodeBusinessLayer->find($id, $this->user())->toSubsonicApi(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
similar to looking up tracks individually, many podcast episodes in a play queue could presumably cause a slow response. Should we include bulk episode find a la bulk track find as part of this work?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, I think that would be good
| 'current' => $current, | ||
| /** @see Util::formatZuluDateTime (if only we could pass a datetime!) */ | ||
| 'changed' => $changedDateTime->format('Y-m-d\TH:i:s.v\Z'), | ||
| 'username' => $u |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should use $this->user() here instead of the URL argument $u. The reason is that the Music app supports the OpenSubsonic extension API Key Authentication which means that the client may pass the argument apiKey instead of u and p. The function will return the correct username in either case.
Another small difference is that the URL argument $u is case insensitive but $this->user() always contains the "proper" casing i.e. the same format in which the username was given when the account was created.
|
Performance-wise, the latest version works great with local songs: While the 100-song queue previously took 25+ seconds, now I can get a 500-song queue in less than a second. 👍 |
| $typeLookupMap = [ | ||
| 'track' => [ | ||
| 'lookupFn' => [$this->trackBusinessLayer, 'findById'], | ||
| 'toApiFn' => [$this, 'trackstoApi'] | ||
| ], | ||
| 'podcast_episode' => [ | ||
| 'lookupFn' => [$this->podcastEpisodeBusinessLayer, 'findById'], | ||
| 'toApiFn' => [$this, 'podcastEpisodestoApi'] | ||
| ] | ||
| ]; | ||
|
|
||
| /** @var array{'track': Track[], 'podcast_episode': PodcastEpisode[]} $apiEntries */ | ||
| $apiEntries = array_merge([], ...array_map( | ||
| fn ($type, $methods) => [$type => $methods['toApiFn']( | ||
| ArrayUtil::createIdLookupTable($methods['lookupFn']( | ||
| \array_map( | ||
| fn ($entry) => $entry[1], | ||
| \array_filter($parsedEntries, fn ($parsedEntry) => $parsedEntry[0] === $type) | ||
| ), | ||
| $this->user() | ||
| )) | ||
| )], | ||
| array_keys($typeLookupMap), | ||
| array_values($typeLookupMap) | ||
| )); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
PHP doesn't necessarily lend itself super great to this sort of programming, so I totally get it if you want to see intermediate vars introduced here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I appreciate the functional programming style but it is true that this is a bit difficult to read or at least requires a lot of focusing to understand it correctly. As a rule of thumb, having more than two level of nesting in control structures is a bit of a code smell, and the same could probably be applied on these array_* functions which are essentially just higher-level control structures. Sometimes the deep nesting might be the best option but at least it's a sing that considering also other design options could be fruitful.
So if you can make it any easier to read with intermediate vars, then that's good. But if not, then that's not a blocker for merging. I'll give this another test round on Sunday or next week. If no problems are found, I'll merge this, unless you want to still change something.
I'm planning to release Music v2.3.0 rather soon, and I'd like to get this merged before that. The deadline for the release is the end of September, before NC32, but preferably I'd like to do it during the next two weeks.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry to chime in, I'm just mostly agreeing and feel free to ignore me, I understand the release is more important, and I don't want to start a long discussion. I don't think PHP gets functional programming right, which you might already know. The best use of FP makes code also declarative and therefore easy to read. However, in PHP, array_map puts the function parameter first, which which poses multiple problems like being inconsistent with array_filter, for example. This makes me want to use a better functional library instead, like Pipeline, but it's just an example, it may not be appropriate to use in a Nextcloud App, and the function chaining may limit you. But at least Pipeline starts the logic with the first thing to do, so you don't read function calls backwards. Also, array_reduce, just like in other languages, is more of a primitive higher-order function, and therefore not so readable; usually, if you can use something better, you should. (Of course this is situational, and may not always be applicable.) I think if the code doesn't mix high- and low-level logic and stays declarative, then it doesn't necessarily harm code readability when you use multiple levels of nesting. It's just that other languages do it better, and more concisely.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the feedback. Really looking forward to a new release and using this with my real library. I made an effort to create a few steps rather than requiring the reader's brain to hold all the transformations. If it's not better, at least it's a little different :D
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's good enough for now. If it starts bothering me, then maybe I'll reconsider the design later.
Anyway, I now tested the latest version and it seemed to run without any issues. Even Dsub worked now without any hick-ups with 500-item queues; maybe the problems I saw in it handling the long queues with the previous play-queue-PR was due to some of the API-noncompliances of that implementation 🤷.
The extra attributes "position" and "current" (track) are saved as JSON comment of the playlist.
- only tracks and podcast episodes allowed - add changedBy, position, and current, then array_filter nulls out - inline formatted changed datetime string
position can be an int, and array_filter without a callback is loosely typed. Scrutinizer found this in a previous commit but misses it here https://scrutinizer-ci.com/g/owncloud/music/inspections/d54ddd52-3e3c-4c11-9111-0ea3911c14a6/issues/files/lib/Controller/SubsonicController.php?status=new&orderField=path&order=asc&honorSelectedPaths=0
refactor playqueue['entry'] generation
- $type is now a sibling of functions in handler array - generate intermediate var for type's entity ids - generate intermediate var for type's entity instances - switch from array_reduce to array_filter'd array_map
…nctions This mechanism enabling a bit more concise expression has been introduced after the first PR for Subsonic play queue support was started.
c14efb5 to
cfa7048
Compare
|
A new inspection was created. |
|
This is now released in Music v2.3.0. |
oc_preferencesOriginal pull request includes discussion of limiting the response size of
getPlayQueue, which isn't implemented at this point, but it's worth discussing